這裡是「Three.js學習日誌」的第25篇,這篇是在講解使用three.js + socket.io打造3D聊天室作品。這系列的文章假設讀者看得懂javascript,並且有Canvas 2D Context的相關知識。
今天我們的目標是要來完成時鐘面板還有兩個地方的聊天室面板~
當然還有socket.io的連線機制
話不多說~就讓我們先來看看今天的完成狀況吧~
這邊我還順便補了一個能夠用來停止方塊旋轉的按鈕
筆者昨天在實際試用的時候發覺...
果然~還是需要有一個停止方塊運動的手段,畢竟這玩意一直自旋,想要看到某一畫面還得一直動手去轉,真的很煩XD。
謎:明明是你自己設計的。
所以我後來還是決定加上一個禁止自旋的按鈕~
不過,因為我們在程式架構上一開始的設計是讓./src/ts/main.ts
底下的main
類別去extends
base
類別,而domCube
和 Cube
又是去偵測base
底下的屬性(touched
)來決定要不要停止旋轉的。
所以這邊的作法就必須要像下面這樣。
我們在base
類別的外面宣告一個變數,用來存放禁止方塊旋轉的狀態。
然後在base
裡面建立get/set
取向的方法,這樣rotationlocaked
就不會被子類別繼承到。
接著就可以在domCube
和 Cube
裡面去用base
提供的get/set
方法來偵測禁止方塊旋轉的狀態。
看起來很簡單吧~
我們目前還有2個空白的方塊面板沒有拿來用,所以我打算拿其中一個來放一隻吉祥物XD。
剩下一面明天再來思考要放些什麼好了XD
這邊動畫的實作我其實不是使用canvas
。而是直接使用CSS @keyframe
簡單來說原理就是利用CSS @keyframe
去動態改變background-image
,然後把所有的動畫幀都用Photoshop
輸出成PNG圖串,每過幾毫秒就變動一次background-image
。
其實就是很簡單的特效,不過其實蠻不錯的吧?
因為我們之前的時鐘其實還一直是只有假字串的狀態,今天把這部分處理完了,雖然這部分其實蠻簡單的,不過還是來看一下大概是怎麼處理~
簡單來說就是在
./src/ts/dom/clock.ts
底下建立渲染畫面的方法,還有setInterval
的計時器~
最後就是聊天室的部分了~!
首先我感覺應該還是有必要來介紹一下Socket.io
的用途,還有這部分我在架構上的規劃。
socket.io
? 他跟Websocket
有什麼關係?socket.io
跟Websocket
的關係有一點點像jquery
和javascript
,也就是包裝庫的概念。
Websocket
又是什麼呢?Websocket
其實是一種通訊協定,就像我們一般瀏覽網頁用的HTTP
協定一樣。
但差別在於HTTP
的連線機制是:
會在每次和伺服器互動的時候,建立「請求(request)」
伺服器確認這個請求之後,就會「回應(response)」對應的資料
而Websocket
的機制則是:
當我們在瀏覽器的Console
介面打上Websocket
這個字的時候,我們其實可以看到瀏覽器環境底下是具備這個同名的函數的。
而這個函數的用途其實只是用來實作Websocket
程序的客戶端部分。
// Create WebSocket connection.
const socket = new WebSocket('ws://localhost:8080');
// Connection opened
socket.addEventListener('open', (event) => {
socket.send('Hello Server!');
});
// Listen for messages
socket.addEventListener('message', (event) => {
console.log('Message from server ', event.data);
});
以上面這張圖片來說,我們可以透過該函數建立客戶端的Websocket
實例,並把該實例的伺服器位址指向localhost:8080
。
這樣做意思就是說在8080
上面其實已經有一台架設好的websocket server
,而我們這邊只是引導客戶端跟這台Server執行「交握」
交握完畢之後,我們在客戶端就會收到Websocket
伺服器傳來的信號。
就有點像我們前面提到過的EventEmitter。
這樣我們就可以決定要在什麼信號發生的時候去對前端畫面做什麼事。
而當然我們也可以從客戶端去發送訊息給websocket server
,像是下面這樣。
socket.send(data)
Websocket
伺服器看起來像什麼樣子?所謂的伺服器
其實就是一台電腦,上面搭載的一段程序。
所以要建立伺服器不一定要使用另外一台電腦,通常測試開發階段我們都是在本機同時run
websocket server
和 websocket client端
。
而如果要編寫websocket server
的程序,其實有很多種電腦語言都可以辦到,但我們這邊當然就是要選用前端工程師比較常聽到的node.js
。
socket.io
和使用Websocket
的差別在哪?首先當然就是效率。
在正常的使用狀況下,想要跟Websocket
伺服器互動還是有分很多的場景,例如多方發送/廣播這種狀況, 使用Websocket
就要自己重新造輪子。
因為
Websocket
就只有指定對象的收跟發,如果要指定對全場成員發送,那就要自己寫出這樣的功能。
我自己的習慣是如果專案的規模不會很大,那就在同一個Repo
底下開一個 server
程序 的資料夾,這邊我把它命名為chat
。
chat
底下會是一個獨立的module
,所以在建立的時候我們必須要先:
cd chat && npm init
接著安裝socket.io
的server端 npm
包。
npm i socket.io
另外我們需要有一個主程序,所以我建立了一個index.ts
。建立完之後其實我們已經可以用node.js
去執行這支index.ts
,就像下面這樣。
node index.ts
不過,因為像這樣的執行方法,其實不會偵測index.ts
本身的改動,並發動重載。所以我們這邊要改用nodemon
來執行這個主程序。
nodemon 就有點像是給node.js程序用的live server
這邊先來安裝nodemon
npm i nodemon
然後就可以用nodemon
來執行主程序(npx 代表的是直接使用專案裡的package,不使用全域package的意思)
npx nodemon index.ts
也可以直接把這段直接寫在
package.json
的script
裡面。
要注意到這邊為止我們都還是位於./chat底下唷。
socket.io
伺服器主程序接下來就是主程序index.ts
的部分。
這邊我其實是參考Jia-yun Yang 寫的 「用 Socket.IO 打造多人聊天室」這篇文章,然後自己修改成
TS
版本。
./chat/index.ts
import { createServer } from "http";
import { Server } from "socket.io";
const app = createServer()
//由於
const io = new Server(app, {
cors: {
origin: "http://192.168.1.101:8080", //這是我自己的IP
methods: ["GET", "POST"]
}
})
/*自訂監聽端口*/
const port = 5500;
app.listen(port);
console.log('app listen at ' + port)
/*用戶陣列*/
const users: { username: string }[] = [];
// 交握
io.on('connection', (socket) => {
/*是否為新用戶*/
let isNewPerson = true;
/*當前登入用戶*/
let username= null;
//監聽登入
socket.on('login', (data) => {
for (var i = 0; i < users.length; i++) {
isNewPerson = (users[i].username === data.username) ? false : true;
}
if (isNewPerson) {
username = data.username
users.push({
username: data.username,
id: socket.id
})
data.userCount = users.length
data.users = users;
/*發送 登入成功 事件*/
socket.emit('loginSuccess', data)
/*向所有連接的用戶廣播 add 事件*/
io.sockets.emit('add', data);
} else {
/*發送 登入失敗 事件*/
socket.emit('loginFail', '');
socket.disconnect();
}
})
//監聽登出
socket.on('logout', (data) => {
/* 發送 離開成功 事件 */
socket.emit('leaveSuccess')
/* 向所有連接的用戶廣播 有人登出 */
users = users.filter((val) => {
return (val.username !== data.username)
})
io.sockets.emit('leave', { username: data.username, userCount: users.length,users:users });
socket.disconnect();
})
socket.on('disconnect', () => {
socket.emit('leaveSuccess')
const userLeft = users.filter((val) => {
return (val.id === socket.id)
})[0]?.username
users = users.filter((val) => {
return (val.id !== socket.id)
})
io.sockets.emit('leave', { username: userLeft, userCount: users.length,users:users })
})
socket.on('sendMessage', function (data) {
/*發送receiveMessage事件*/
io.sockets.emit('receiveMessage', data)
})
})
上面的程式碼,筆者因為有發現錯誤,所以在2022.10.16進行過修改,如對觀眾朋友們造成困擾,深感抱歉。
值得一提的是在socket.io v3
版本之後,我們會需要在伺服器端上建立CORS
的白名單。
如果不懂什麼是CORS可以看這邊
const io = new Server(app, {
cors: {
//http://192.168.1.101:8080是我自己當前的區網IP,
//我把server架設在http://192.168.1.101:5500
// 客戶端的port則是調整了webpack的host設定,定在了8080
origin: "http://192.168.1.101:8080",
methods: ["GET", "POST"]
}
})
白名單的意思也就是說「不會去排除特定網域的客戶端交握行為」。如果我們剛剛在上面沒有設定cors
這個屬性,那在我們按下登入按鈕的時候就會出現下面這個錯誤。
socket.io
客戶端最後就是客戶端的部分了。
我目前是先把socket.io
客戶端的程序放在 ./src/ts/main.ts
裡面,這邊我們來看看都寫了些什麼~
import { Base } from './class/base';
import { io, Socket } from 'socket.io-client';
import { trim } from 'lodash';
class Main extends Base {
// 個人習慣把抓取的元素都放置在一個區塊的最上面,這樣才方便找。
// 原則上我們應該要避免一直去query元素,因為會對程式效能帶來影響。
private wrapper: Element = document.querySelector('#wrapper');
private chatBlock: Element = document.querySelector('#chat-block');
private chatBlockActive = false;
private socket: Socket = io('ws://192.168.1.101:5500');
private myName: string;
constructor(canvas: HTMLCanvasElement, domCanvas: HTMLElement, domBundle: HTMLElement) {
super(canvas, domCanvas, domBundle);
this.initChatUI();
this.initChatSocket();
}
// ui操作後發動的事件綁定
private initChatUI() {
const toggler = this.chatBlock.querySelector('#chat-block-toggler');
const rotationLock = this.chatBlock.querySelector('#rotation-lock');
const loginBtn = this.chatBlock.querySelector('#login-button');
const sendBtn = this.chatBlock.querySelector('#send-message-button');
const logoutBtn = this.chatBlock.querySelector('#logout-button');
toggler.addEventListener('click', () => {
if (this.chatBlockActive) {
this.wrapper.classList.remove('wrapper--active');
}
else {
this.wrapper.classList.add('wrapper--active');
}
this.chatBlockActive = !this.chatBlockActive;
})
// 這邊是禁止旋轉按鈕的事件綁定
rotationLock.addEventListener('click', () => {
const status = this.getRotationLockStatus();
if (status) {
rotationLock.classList.remove('chat-block__rotation-lock--active');
}
else {
rotationLock.classList.add('chat-block__rotation-lock--active');
}
this.toggleRotationLock(!status)
})
//登入按鈕的事件綁定
loginBtn.addEventListener('click', () => {
this.myName = trim((this.chatBlock.querySelector('#login-name') as HTMLInputElement).value);
if (this.myName) {
/*發送事件*/
this.socket.emit('login', { username: this.myName })
} else {
alert('Please enter a name :)')
}
})
//送信按鈕的事件綁定
sendBtn.addEventListener('click', () => {
this.sendMessage();
})
// 登出按鈕的事件綁定
logoutBtn.addEventListener('click', () => {
let leave = confirm('Are you sure you want to leave?')
if (leave) {
/*觸發 logout 事件*/
this.socket.emit('logout', { username: this.myName });
}
})
// 當用戶按下enter的時候要送信
document.addEventListener('keydown', (evt: KeyboardEvent) => {
if (evt.keyCode == 13) {
this.sendMessage()
}
})
}
private initChatSocket() {
/*登入成功*/
//當收到socket的登入成功信號
this.socket.on('loginSuccess', (data) => {
if (data.username === this.myName) {
this.checkIn(data)
} else {
alert('Wrong username:( Please try again!')
}
})
/*登入失敗*/
//當收到socket的登入失敗信號
this.socket.on('loginFail', () => {
alert('Duplicate name already exists:0')
})
/*加入聊天室提示*/
//當收到socket的有其他人登入成功信號
this.socket.on('add', (data) => {
var html = `<p>${data.username} 加入聊天室</p>`
// $('.chat-con').append(html);
document.getElementById('chat-title').innerHTML = `在線人數: ${data.userCount}`
})
//離開成功
//當收到socket的離開成功信號
this.socket.on('leaveSuccess', () => {
this.checkOut()
})
//退出提示
//當收到socket的有其他人離開成功的信號
this.socket.on('leave', (data) => {
if (data.username != null) {
let html = `<p>${data.username} 退出聊天室</p>`;
// $('.chat-con').append(html);
// document.getElementById('chat-title').innerHTML = `在線人數: ${data.userCount}`;
}
})
//收到訊息
//當收到socket的有人發送訊息信號
this.socket.on('receiveMessage', (data) => {
this.showMessage(data)
})
}
private checkIn(data: any) {
const loginWrapper = this.chatBlock.querySelector('#login');
const userNameEle = this.chatBlock.querySelector('#my-name');
userNameEle.innerHTML = data.username;
loginWrapper.classList.add('login--logined');
}
private checkOut() {
const loginWrapper = this.chatBlock.querySelector('#login');
loginWrapper.classList.remove('login--logined');
}
private sendMessage() {
const inputEle = this.chatBlock.querySelector('#message-input');
const message = (inputEle as HTMLInputElement).value;
(inputEle as HTMLInputElement).value = ''
if (message) {
/*觸發 sendMessage 事件*/
this.socket.emit('sendMessage', { username: this.myName, message: message });
}
}
// 把訊息內容渲染出來
private showMessage(data: any) {
let html;
if (data.username === this.myName) {
html = `<div class="chat-main__chat ">
<div class="chat-main__bubble-name">You</div>
<div class="chat-main__bubble">${data.message}</div>
</div>
`;
} else {
html = `<div class="chat-main__chat chat-main__chat--other">
<div class="chat-main__bubble-name">${data.username}</div>
<div class="chat-main__bubble">${data.message}</div>
</div>
`;
}
const ele = this.createElementFromHTML(html)
this.chatBlock.querySelector('#chat-main').appendChild(ele);
this.wrapper.querySelector('#chat-main-cube').appendChild(ele.cloneNode(true));
}
// 從字串產生html元素
private createElementFromHTML(htmlString: string) {
const div = document.createElement('div');
div.innerHTML = htmlString.trim();
// Change this to div.childNodes to support multiple top-level nodes.
return div.firstChild;
}
}
基本上我這邊的區塊劃分就是把「UI
操作之後綁定的動作」,和「socket
偵測到某特定信號之後的操作」個別分成一類。
筆者個人覺得把特定形式的程式碼分在同一個方法會相對地比較好整理。
我們這個作品已經接近完成了~ 大概明天就會是這個部分的最後一篇,再麻煩各位持續追蹤~